11. 采集路径并发安全与恢复能力设计

版本:v1.0 范围:internal/core(ScanEngine / ChannelManager / ExecutionLayer / ShadowCore)+ internal/driver(13 协议驱动 + ConnectionManager) 目标:消除采集链路上的死锁与数据竞态,并建立工业级故障恢复闭环(异常检测 → 隔离 → 探测 → 恢复)。 原则:只做收敛与加固,不以牺牲稳定性换取吞吐。


0. 结论摘要(TL;DR)

类别 修复前 修复后
确定性死锁 EtherNet/IP 重连必然自锁死(t.mu 重入) 消除(resetConnection 独立临界区)
并发重复执行 防饿死对已在队列的任务重复入堆 → 同一任务双 worker 并发执行 消除(ScanTask.queued 队列成员位 + heap.Fix)
进程级崩溃 驱动 panic 在 worker goroutine 中无 recover → 整进程退出 消除(readPoints + executeTaskAsync + 调度/巡检循环 recover)
并发 map 崩溃 task.Params 原地改写 vs 热路径无锁读 → fatal error: concurrent map read and map write 消除(Params copy-on-write)
通道卡死无恢复 链路断开后无周期性重连 → 只能重启进程 消除(通道健康看门狗 + 冷却)
大锁阻塞 OPC UA / BACnet 持驱动全局锁做网络 RPC 收敛(快照后释放锁再 I/O)

1. 采集路径清单(逐一列出)

采集数据从调度到影子/管道共 6 层。下表是全部路径及其写者/读者。

1.1 端到端主链路

# 阶段 位置 并发执行者 关键共享状态
P1 任务注册 ScanEngine.addTask ChannelManager(持 cm.mu) tasks map、priorityQueue
P2 到期判定/出队 dispatchLoop → processReadyTasks → popReadyTaskEDF ScanEngine dispatch 单协程 priorityQueue、task.Status
P3 抖动钳制/防饿死 enforceHardJitterClamp、enforceAntiStarvation dispatch 单协程 priorityQueue、overdueWarnAt
P4 执行(异步) go executeTaskAsync 每任务 1 协程(受 ResourceController 限流) ResourceController 计数
P5 协议 I/O ExecutionLayer.Execute → execute{Serial,Parallel,Limited} → readPoints → driver.ReadPoints SerialQueue 每设备 1 协程 / WorkerPool N 协程 channelMu(共享链路)、驱动内部锁
P6 结果落地 applyCollectToShadow → ShadowIngress → ShadowCore(COW) → ShadowBridge → DataPipeline 入口协程 + flush 协程 bufferMu、sc.mu、dp.mu
P7 设备/通道状态回写 finalizeScanCollect → NodeStatus / markChannelDevicesOffline 入口协程 / API 协程 node.Runtime、stateManager

1.2 协议驱动路径(13 类)

协议 驱动结构体 传输层锁 共享链路串行化 重连入口 恢复机制
bacnet-ip BACnetDriver mu RWMutex + DeviceContext.ReadMu/CacheMu channelMu(已绑定) ScheduleReconnect + scheduleDeviceRecovery 通道级 + 设备级隔离/探测
modbus-tcp/rtu/rtu-over-tcp ModbusDriver ModbusTransport.mu Mutex + PointScheduler.mu channelMu(已绑定) ScheduleReconnect 点位冷却 SKIPPED + 链路错误豁免
dlt645 DLT645Driver DLT645Transport.mu Mutex channelMu(已绑定) scheduleReconnect 冷却 + 发送节流
knxnet-ip KNXnetIPDriver KNXTransport.mu/writeMu/cacheMu channelMu(已绑定) connectOnce 心跳循环
opc-ua OpcUaDriver mu RWMutex + ClientWrapper.mu + DeviceSubscription.mu 驱动内锁 onReconnectNeeded → scheduleReconnect 订阅重连
s7 S7Driver S7Transport.mu Mutex — scheduleReconnect 主动探针(唯一生产启用)
ethernet-ip EtherNetIPDriver ENIPTransport.mu Mutex — scheduleReconnect 失败计数触发
ethercat EtherCATDriver cycleRunning + sync.Map + rxBuffer.mu — connectOnce PDO 周期线程 cycleWG
ice104 ICE104Driver writeMu + cacheMu + readWG — connectOnce 读循环 cancel + WaitGroup
profinet-io ProfinetIODriver ProfinetTransport.mu + ProfinetRPC.mu — 手动退避循环 ensureDeviceConnection
mitsubishi-slmp MitsubishiDriver MCTransport.mu Mutex 未绑定 — 无连接管理器
omron-fins OmronFinsDriver tcpBackend.mu / udpBackend.mu 未绑定 — 无连接管理器
snmp SNMPDriver atomic 计数 未绑定 — 无连接管理器

已知缺口(未在本次修复):omron-fins / mitsubishi-slmp / snmp 在 isSharedLinkProtocol 中被判定为共享链路,但既未实现 drv.LinkMutexBinder,其 transport 也没有 ConnectionManager → 其同步 Connect 拨号不受 channelMu 串行化。见 §6 改进建议。


2. 锁与并发控制机制

2.1 锁清单(类型 / 粒度 / 保护对象)

层 锁 类型 粒度 保护对象
L1 ChannelManager.mu RWMutex 全局拓扑 channels、drivers、driverMus、jobs
L1’ ChannelManager.topologyDebounceMu Mutex 拓扑去抖 topologyDebounceTimer
L2 ScanEngineAdapter.mu Mutex 设备注册 driverManager、started
L3 ScanEngine.mu RWMutex 调度器 tasks、priorityQueue、running、overdueWarnAt、taskIDCounter
L4 ScanTask.mu RWMutex 单任务 Status、NextRun、DeadlineAt、Priority、Interval、queued、Params(COW)
L5 ExecutionLayer.mu RWMutex 执行层注册表 protocolRegistry、driverRegistry、pointDegradation、ioProfileProvider
L6 SerialQueueManager.mu RWMutex 共享链路队列 contexts
L7 ResourceController int32 + atomic 并发上限 goroutineCount、connectionCount
L8 ConnectionManager.mu / bgMu Mutex 连接状态机 state、retryCount、cooldown、linkMu、bgCancel/bgDone
L9 driverMus[channelID](=channelMu) Mutex 单通道 共享链路 拨号 + I/O 串行化
L10 驱动内部锁 Mutex/RWMutex 单驱动 连接指针、缓存、统计
L11 ShadowCore.mu + atomic.Pointer Mutex + COW 影子设备 快照发布(不可变)
L12 ShadowIngress.bufferMu Mutex 影子入口缓冲 ring buffer 批次
L13 DataPipeline.mu Mutex 管道 批次交换
L14 NodeStatus.node.mu Mutex 单设备状态 Runtime

2.2 加锁顺序(强制,不可逆)

cm.mu(W)  →  adapter.mu  →  se.mu  →  task.mu
                                      ↑
                            (所有 heap 操作都在 se.mu 内,
                             Less/Swap 内部取 task.mu.RLock)

旁路(不得与主链交叉持有):
  driverMus(channelMu)  →  driver.mu        ← runConnect 与 scan 读都遵守
  feedbackPendingMu     →  task.mu
  ShadowCore.mu         →  optimizer.mu

三条铁律(本次修复后的代码约束):

  1. sync.RWMutex 不可重入:已持 task.mu.Lock() 的路径禁止调用会再次取 task.mu 的方法。为此新增 taskShadowChannelIDLocked / taskDegradeOnFailureLocked / 直读 task.Params 的「Locked 变体」。
  2. 持 channelMu 期间只做拨号与 I/O,不得回调 ChannelManager 持 cm.mu 的方法(BACnet notifier 已用 applyDiscoveredDeviceLocked 规避)。
  3. 锁内不做网络 I/O:OPC UA / BACnet 已改为「加锁取快照 → 解锁 → 网络调用 → 回锁提交」。

2.3 并发原语选择理由

场景 采用 理由
任务状态字段 RWMutex 读多写少(每 tick 读、每轮写)
队列成员位 queued 随 se.mu 保护 + task.mu 短持 所有 heap 变更已在 se.mu 下,标志位与堆操作同临界区才可靠
连接状态/计数 atomic.Bool/Int32/Int64 无锁热路径
影子快照 COW + atomic.Pointer 读者永不见中间态,无锁读取
并发上限 atomic 计数 避免锁竞争
Params 配置 copy-on-write 消除「并发 map 读写」致命错误

3. 竞态条件场景(触发条件 + 复现路径)

C1 防饿死重复入队 → 同一任务并发双执行 【已修复】

  • 触发条件:任务被 CanExecute()==false 回推入堆后,其 NextRun 逾期 > AntiStarvationSec(默认 300s)。
  • 复现路径:enforceAntiStarvation 遍历 se.tasks(含已在堆中的任务)→ 无条件 heap.Push → 同一 *ScanTask 出现两份堆条目 → popReadyTaskEDF 对 Running 状态不做过滤 → 两个 worker 并发执行同一任务 → 竞写 task.Status / task.Points[i].DeviceID / rescheduleTask。
  • 复现测试:TestAntiStarvation_RepeatedRescueKeepsSingleQueueEntry(断言队列长度恒为 1)。
  • 修复:新增 ScanTask.queued;救援时若已在队列 → heap.FixLocked,否则 Push 并置位。

C2 EtherNet/IP 重连自死锁 【已修复】

  • 触发条件:collectFailCount >= maxFailCount 触发 scheduleReconnect,且拨号成功。
  • 复现路径:重连闭包 t.mu.Lock() → defer Unlock → 调 t.connectOnce → 内部再次 t.mu.Lock()(安装连接)→ 不可重入 Mutex 永久阻塞,t.mu 永不释放 → 后续 Read/Write/Disconnect 全部冻结,只能重启进程。
  • 复现测试:TestENIPTransport_ScheduleReconnectDoesNotHoldLockWhileDialing(在拨号 factory 内 TryLock 探测,持有则失败)。
  • 修复:抽出 resetConnection()(自持锁、快返回),在闭包中先复位再调 connectOnce。

C3 task.Params 并发 map 读写 → 进程崩溃 【已修复】

  • 触发条件:UpdateTaskDriverConfig(BACnet 地址自动发现 / API)与扫描执行并发。
  • 复现路径:写者持 se.mu 原地改写 task.Params["driverConfig"];读者(execution_layer.readPoints / taskShadowChannelID / taskDegradeOnFailure)无锁读同一 map → Go 运行时 fatal error: concurrent map read and map write(不可 recover)。
  • 复现测试:TestUpdateTaskDriverConfig_ConcurrentWithReaders(-race)。
  • 修复:Params 全量 copy-on-write,setParams 原子发布;读者经 paramsSnapshot() 取不可变引用。

C4 驱动 panic 击穿进程 【已修复】

  • 触发条件:驱动 ReadPoints 内 panic(畸形帧、越界、CGO 段错误)。
  • 复现路径:executeSerial/Parallel 把 ReadFunc 提交到 SerialQueueManager / WorkerPool 的 worker 协程 → 该协程无 recover → panic 逃逸 → 进程退出,丢失全部内存态(熔断/降级/隔离)。
  • 复现测试:TestExecutionLayer_RecoversDriverPanic、TestExecuteTaskAsync_RecoversPanicAndRearmsTask。
  • 修复:在真正的执行边界 ExecutionLayer.readPoints 加 recover(覆盖 Serial/Parallel/Limited 全部路径),把 panic 转成普通读错误交给熔断器;executeTaskAsync 再兜底 recover 并重新武装任务。

C5 调度循环 panic → 全局停摆 【已修复】

  • 触发条件:processReadyTasks 内部 panic(堆结构被破坏、指标异常)。
  • 复现路径:dispatchLoop 是唯一调度协程,无 recover → 协程死亡 → 所有设备永久停止采集(直到进程重启)。
  • 修复:safeProcessReadyTasks recover + rebuildQueueAfterPanic(从 se.tasks 重建堆,丢弃重复/悬垂指针,重算 queued)——即状态回滚。

C6 通道链路断开后无周期性重连 【已修复】

  • 触发条件:驱动通信层半挂(如 BACnet UDP socket 未重启),Health()==Bad,但驱动自身未触发重连。
  • 复现路径:isChannelLinkUp==false 仅被被动查询;core 层只在 StartChannel 失败与 finalizeScanCollect 链路错时动作 → 通道长期 offline,只能人工重启进程。
  • 复现测试:TestChannelHealthWatchdog_TriggersReconnectWithCooldown。
  • 修复:ChannelManager 新增 15s 健康看门狗;链路 down 且距上次尝试 ≥30s 时,对实现 ReconnectScheduler 的驱动触发 ScheduleReconnect。

C7 OPC UA / BACnet 持全局锁做网络 RPC 【已收敛】

  • 触发条件:重连或懒连期间。
  • 后果:d.mu 被持 10s(OPC UA connect timeout),期间 Health()/GetMetrics()/其他设备读全部阻塞。
  • 修复:改为「加锁取快照 → 解锁 → Connect(ctx) → 回锁提交」。

C8 其余已识别但本次未改动的风险(登记在案)

编号 位置 风险
R1 bacnet.Health() 无锁读 d.connected/d.client(与写者竞态)
R2 bacnet.connectOnce 持 d.mu 调 StopBackgroundLoop,锁序与 Disconnect 不一致
R3 NodeStatus.MarkOffline/onCollectUnreachable 未持 node.mu,与 onCollectSuccess 保护不一致
R4 DataPipeline.Stop 与 PushBatch 交错存在 send on closed channel 窗口
R5 ScanEngine.RemoveTasksByDeviceKey feedbackPending 残留未清理(泄漏)
R6 ResourceController.ConnectionLimit connectionCount 无生产递增 → 该上限失效
R7 omron/mitsubishi/snmp 共享链路但未绑定 channelMu 且无 connMgr
R8 SoakMonitor/GCMonitor 循环 无 recover

4. 稳定性指标定义

指标全部可从 ScanEngineMetrics.Snapshot() / CircuitBreaker.Snapshot() / 通道诊断 API 读取。

4.1 调度连续性

指标 键名 定义
任务执行总数 tasks_executed 累计派发执行次数
成功率 tasks_succeeded / tasks_executed 采集成功占比
调度滞后 P95 scan_lag_p95_ms 实际开始时间 − 计划时间 的 P95
扫描漂移均值 scan_drift_avg_ms 周期偏移均值
错过截止 scan_miss_deadline_total 超 DeadlineAt 才派发的次数
防饿死救援 starvation_rescue_total 触发救援次数(应长期为 0)

4.2 恢复能力(本次新增)

指标 键名 定义
采集 panic 次数 task_panics_total 必须告警,正常应恒为 0
任务重新武装次数 task_recoveries_total panic/异常后恢复入队次数
熔断打开次数 circuit_breaker_open_total 设备级熔断触发数
熔断拒绝次数 circuit_breaker_reject_total 短路保护命中数
通道重连次数 RecordReconnect 看门狗 / 驱动触发重连数

4.3 数据一致性

指标 定义
点位质量分布 Good / Uncertain / Bad 占比
影子陈旧值兜底 preservedShadowValue 命中次数(Bad 时保留末次 Good)
点位降级数 PointDegradationManager 降级点位数
设备有效状态 resolveEffectiveDeviceState 的 Online/Unstable/Offline/Quarantine 分布

4.4 稳定性的三类量化口径

  • 连续无故障运行时长:进程启动后 task_panics_total==0 且 starvation_rescue_total 增量==0 的最长连续时段(由 SoakMonitor 采样)。
  • 异常恢复率 = 恢复成功次数 / 异常触发次数:
    • 连接维度:RecordSuccess / (RecordSuccess + RecordFailure)(ConnectionManager)
    • 任务维度:task_recoveries_total / task_panics_total(目标 100%,每个 panic 都重新武装)
    • 熔断维度:HalfOpen 探测成功率
  • 数据一致性:同一设备同一时刻,Shadow 值与最近一次 Good 采集一致(quality != Good 时值必须来自 preservedShadowValue,不得出现撕裂/中间态)。

5. 验收标准(可量化)

5.1 正确性(必须全绿)

# 条件 验证方式
A1 并发执行下无数据竞态 go test -race ./internal/core/... ./internal/driver/... 零报告
A2 无确定性死锁 全部新增死锁回归测试通过(ENIP / BACnet 重启 / 并发 Connect)
A3 防饿死不产生重复队条目 TestAntiStarvation_RepeatedRescueKeepsSingleQueueEntry 队列长度恒为 1
A4 任何驱动 panic 不导致进程退出 TestExecutionLayer_RecoversDriverPanic 通过
A5 panic 后任务不丢 TestExecuteTaskAsync_RecoversPanicAndRearmsTask 断言任务重新入队
A6 Params 并发读写无致命错误 TestUpdateTaskDriverConfig_ConcurrentWithReaders(-race)通过
A7 全量测试无回归 go test -short ./internal/... 全 PASS

5.2 稳定性(SLO)

指标 验收阈值
task_panics_total = 0(任何增长视为 P0 缺陷)
starvation_rescue_total 增量 = 0(稳态 72h 内不增长)
scan_lag_p95_ms ≤ 100ms
scan_drift_avg_ms ≤ 50ms
scan_miss_deadline_total 增量 = 0
circuit_breaker_reject_total 增量 = 0(稳态)
通道重连成功率 ≥ 99%(链路恢复后 30s 内自动回到 Good)
异常恢复率 = 100%(panic 必重新武装;断链必自动重连)

5.3 回归基线(本次实测)

项 结果
internal/core(跳过既有脆弱 HTTP 用例) PASS
internal/driver/...(13 协议) PASS
新增并发/恢复测试(-race) PASS
EtherNet/IP 死锁复现测试(回滚修复时) FAIL → 证明测试有效
防饿死重复入队复现(回滚修复时) FAIL → 证明测试有效

6. 恢复机制设计(异常检测 → 自动重启 → 状态回滚 → 断线重连)

6.1 异常检测

检测器 位置 触发条件 动作
熔断器 circuit_breaker.go 连续 5 次超时 或 60s 窗口失败率 > 40%(≥10 样本) 打开 30s → HalfOpen 探针
点位降级 point_degradation_manager.go 连续失败 ≥3 指数退避探测(5s→5min)
驱动点位冷却 modbus/scheduler.go 3 次→60s;10 次→5min;非法地址永久跳过(链路错误豁免) 跳过该点,不解冻设备
设备隔离 bacnet/isolation.go 连续 3 次失败 指数退避 1min→1h + 抖动
健康探针 ReadPoints 前置 Health() Health()!=Good 触发重连 / 短路
panic 计数 ExecutionLayer.readPoints 驱动 panic 转为读错误 + task_panics_total++
通道健康巡检 ChannelManager.channelHealthLoop 链路 down 且超冷却 触发 ScheduleReconnect

6.2 自动重启(自愈)

层级 机制
任务级 panic 后 rearmTaskAfterPanic:置 Idle → 重排 → 重新入队(task_recoveries_total++)
调度级 safeProcessReadyTasks:recover + 队列重建,调度协程永不死亡
连接级 ConnectionManager.EnsureConnected:指数退避 + 冷却 + 全局限流(10 次/秒)
通道级 健康看门狗 15s 巡检 → ScheduleReconnect
进程级 systemd Restart=always / RestartSec=5(最后兜底)

6.3 状态回滚

场景 回滚动作
调度循环 panic,堆结构可能损坏 rebuildQueueAfterPanic:从权威 tasks map 重建优先队列,丢弃重复/悬垂条目,重算 queued
任务 panic 状态回退为 Idle 并重置调度锚点
采集失败 preservedShadowValue 保留末次 Good 值 + quality=Bad,避免下游读撕裂值
配置变更 Params copy-on-write:旧引用保持自洽,读者不会读到半更新配置
点位配置变更 Modbus 自动清除永久跳过/冷却

6.4 断线重连

链路异常
   │
   ├─ 驱动自检测(失败计数达阈值)──► ScheduleReconnect
   ├─ 通道看门狗(Health()==Bad)───► ScheduleReconnect   ← 本次新增
   ├─ 采集收尾链路错 ────────────────► markChannelDevicesOffline + 设备级恢复探针
   └─ StartChannel 连接失败 ─────────► markChannelDevicesOffline + ScheduleReconnect
                                          │
                                          ▼
                              ConnectionManager.EnsureConnected
                       (退避 → 冷却 → 全局限流 → linkMu 串行化拨号)
                                          │
                                          ▼
                                     RecordSuccess → 恢复

串行化保证:runConnect 持 channelMu(linkMu)执行 connectOnce,与扫描 I/O、API 写互斥,避免”重连拨号与在用 socket 打架”。要求驱动实现 drv.LinkMutexBinder 完成注入。

6.5 参考 Kepware 的简化

Kepware 的核心做法是 “把复杂度从热路径赶到配置/诊断层”,本次据此做了三处简化:

  1. 冷却/降级阈值集中在驱动内:Modbus PointScheduler 用固定阈值表(3 次→60s,10 次→5min)+ 一个哨兵时间 permanentSkipUntil 表达”永久跳过”,不引入状态机对象。
  2. 合并状态视图:不新增统一恢复状态机(避免 5 套枚举互转的复杂度),而是保留各层既有状态,通过指标 + 看门狗做单向驱动——恢复动作只依赖 Health() 与失败计数两个简单信号。
  3. 队列成员位替代“查重”:用 ScanTask.queued 一个布尔位取代”每次救援时线性扫描队列查重”,把 O(n) 变成 O(1),与 Kepware 用”标签状态位”避免重复入队同构。

7. 变更清单(本次实现)

文件 变更
internal/core/scan_engine.go ScanTask.queued + isQueued/setQueued;paramsSnapshot/setParams;taskShadowChannelIDLocked/taskDegradeOnFailureLocked;防饿死改 heap.Fix;RemoveTask 清队列;executeTaskAsync recover + rearmTaskAfterPanic;safeProcessReadyTasks + rebuildQueueAfterPanic;safeSLAWarnings
internal/core/execution_layer.go readPoints 加 panic recover(命名返回值);Params 读取走 paramsSnapshot
internal/core/scan_engine_metrics.go 新增 task_panics_total / task_recoveries_total
internal/core/channel_manager.go 新增通道健康看门狗(channelHealthLoop / checkChannelHealth / safeCheckChannelHealth)
internal/driver/ethernetip/transport.go 新增 resetConnection,修复重连自死锁
internal/driver/opcua/opcua.go 重连/懒连网络 RPC 移出 d.mu
internal/core/concurrency_invariants_test.go 新增 7 个并发不变量/恢复测试
internal/core/channel_health_watchdog_test.go 新增 2 个看门狗测试
internal/driver/ethernetip/reconnect_deadlock_test.go 新增 2 个重连死锁回归测试

8. 后续建议(按优先级)

  1. 补齐共享链路绑定:为 omron-fins / mitsubishi-slmp / snmp 实现 LinkMutexBinder(必要时引入 ConnectionManager),消除 R7。
  2. 修复 R1–R5:BACnet Health() 加锁、connectOnce 锁序对齐 Disconnect、NodeStatus 保护一致化、DataPipeline 关闭竞态、feedbackPending 清理。
  3. 恢复状态持久化:熔断/降级/隔离内存态重启即丢,建议落 bbolt 以支持”重启后维持隔离梯度”。
  4. 进程级看门狗:为关键循环统一加 recover 后,可增加”心跳文件 + systemd watchdog”实现进程级卡死检测。